Published on

[Trouble Shoot] - 2023.11.09 페이징 성능 개선

Authors
  • avatar
    Name
    Woojin Son
    Twitter

😀 Intro

최근에 Spring, JPA, QueryDSL을 통해 서비스를 개발하면서 여러 이슈가 있었지만, 가장 기억에 남는 이슈는 금주에 발생한 페이지 성능 개선 이슈입니다.

페이징을 구현하는 과정에서 성능이 저하될 수 있다는 사실은 익히 들어 알고 있었습니다. Offset 기반의 페이징은 성능 이슈에 신경을 쓸 수밖에 없습니다. 그럼에도 막상 Frontend와 연동했을 때 성능 이슈가 발생하였고, 해당 문제를 해결하느라 며칠 간 고생을 했습니다.

우선 팀에서는 1차적으로 페이징 쿼리에 부하를 주지 않기 위해 3년 치 범위의 데이터만 조회하는 방향으로 문제의 범위를 좁혔습니다. 조회되는 데이터의 범위를 픽스 하는 것 만으로도 테스트 커버리지가 무한정 늘어나지 않을 수 있었습니다.

그럼에도 실제 서버에 배포했을 때 페이지 조회 시 1초 이상의 시간이 소요되는 문제가 발생했습니다. 아직 테스트 기간이라 상관은 없었지만, 실제 서비스로 릴리즈 되었을 때 큰 불만 사항으로 이어질 수 있는 문제였습니다.

페이징 방법을 바꾸기엔 Pagination 버튼 기능을 사용하기 위해선 결국 offsetlimit에 대한 정보가 필요했습니다. 그리고 총 데이터가 얼마나 조회되었고, 몇 개의 페이지가 생성 되었는지에 대한 정보가 필요했기에 방법을 바꾸는 게 문제는 아니었습니다.

이번 포스팅에서는 이 페이징 성능 저하 문제를 어떻게 해결하였는지에 대해 이야기 해 보겠습니다.

🧐 페이징 방식에 대한 고찰

단순히 성능을 개선하기 위해 Offset 기반 페이징을 포기하는 것은 문제가 있습니다. 페이징 방법은 성능이 아닌 요구사항에 따라 달라지는 것이니까요.

요구사항에 페이지네이션 버튼이 존재해야 한다면, 그리고 총 검색 결과를 출력해야 한다면 No Offset 방식의 페이징이 반드시 정답이 될 수 없습니다. 복잡한 정렬조건이 들어가게 된다면 또 얘기는 달라지죠.

반드시 Offset 기반의 페이징을 구현해야만 할 때 성능에 이슈가 생기지 않으려면 두 가지 방법을 고려해볼 수 있습니다.

커버링 인덱스

커버링 인덱스란 쿼리를 충족시키는 데 필요한 모든 데이터를 가지고 있는 인덱스를 의미합니다. 쉽게 얘기하면 쿼리 상에 포함되는 모든 컬럼이 Index 컬럼에 포함 되는 경우입니다. 보통은 Select 절을 제외 한 나머지 컬럼들은 index 가 걸린 컬럼을 우선으로 사용하는 방법입니다.

SELECT *
FROM TABLE_DATA
WHERE 1=1
    AND ...
ORDER BY seq DESC
OFFSET pageNo
LIMIT pageSize

일반적인 페이징 쿼리입니다. 커버링 인덱스를 사용한다면 아래와 같이 변화할 수 있습니다.

SELECT *
FROM TABLE_DATA as d
JOIN (SELECT seq
        FROM TABLE_DATA
        WHERE 1=1
            AND ...
        ORDER BY seq DESC
        OFFSET pageNo
        LIMIT pageSize) AS tmp ON tmp.seq = d.seq

TABLE_DATA 의 seq 에 index가 걸려있다고 가정해보면, 1차적으로 인덱스를 통해 항목들을 빠르게 걸러서 조회할 수 있습니다.

필요한 값을 캐싱하기

여기에 추가로 페이지 조회가 처음 일어날 때 필요한 값을 캐싱하는 방법으로도 문제를 해결할 수 있습니다. Offset 기반 페이징이 이뤄질 때 또 다른 성능 저하 요인은 Count 쿼리입니다. 매번 페이지 쿼리를 날릴 때 Count만 줄일 수 있어도 적지않은 성능 개선을 기대할 수 있습니다. 하지만 Count 값 자체는 반드시 필요합니다. 페이지의 총 갯수를 계산하는 데 사용되기 때문입니다.

public abstract class BasePageRes<T> {
    private List<T> contents;
    private int pageNo;
    private boolean isLast;
    private int totalPages;
    private long totalElements;
}

제가 주로 사용하는 페이징 API Response VO 패턴입니다. Spring 의 Page 객체에서 Frontend에 필요할 법한 데이터들이 대부분 명세되어 있습니다. Frontend는 해당 형태로 전달 된 Response 중 totalElements 를 캐싱할 수 있겠죠. 그럼 다음 페이지에 대한 요청부터는 해당 값을 전달하는 방식으로 문제를 해결 할 수 있습니다.

물론 실시간으로 계속해서 업데이트 되는 데이터에 대한 대응까지 들어가야 한다면 해당 방법은 사용할 수 없습니다.

😥 실제로 페이징이 느려진 이유는?

문제는 조인이었습니다. 페이지를 생성하고 보여주는 과정에서 하나의 요구사항에 대한 잘못된 대응이 성능 저하를 일으켰습니다.

@Entity
public class Posting {

    @Id
    private Long id;
    private String writer;
    private String title;
    private String contents;

    @OneToMany(mappedBy = "posting", orphanRemoval = true,
        cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    private List<CategoryInfo> categoryInfo = new ArrayList<>();
}
@Entity
public class CategoryInfo {

    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "POSTING_SEQ")
    private Posting posting;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "CATEGORY_SEQ")
    private Category category;

}
@Entity
public class Category {

    @Id
    private Long id;
    private String categoryName;
    private String categoryCode;

    @OneToMany(mappedBy = "category", orphanRemoval = true,
        cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    private List<CategoryInfo> categoryInfo = new ArrayList<>();
}
@Entity
public class CategoryClosure {

    @Id
    private Long id;
    private Long ascendantId;
    private Long descentantId;
    private int level;
}

서비스는 대략 이런 형태로 설계 되어있었습니다. (어디까지나 예시입니다.)

게시물에 대한 카테고리 정보가 있고, 게시글과 카테고리 사이 다대다 매핑을 해주는 테이블이 존재했습니다. 그리고 카테고리 간 상하관계는 클로저 테이블을 통해 관리하고 있었습니다.

클로저 테이블은 계층 관계가 있는 데이터를 다룰 때 주로 쓰이는 패턴인데, 처음엔 카테고리 내에 계층 관계를 서술하려다 복잡해질 것 같아서 계층 관계만 따로 관리하는 테이블을 분리하기로 결정했고, 쉽고 간편한 방법이라 판단한 클로저 테이블을 선택했습니다.

요구사항은 데이터에 대한 페이징 조회 시 대분류 / 중분류 / 소분류 형태로 데이터를 보여달라는 것이었습니다. 예시는 아래와 같습니다.

게시글 번호제목작성자카테고리
5게시글제목5테스트5게시판/자유게시판/Q&A
4게시글제목4테스트4게시판/자유게시판/잡담
3게시글제목3테스트3게시판/자유게시판/TIP
2게시글제목2테스트2게시판/자유게시판/잡담
1게시글제목1테스트1게시판/자유게시판/Q&A

게시글 테이블을 페이징 하는 것은 큰 문제가 아니었지만, JPA를 사용하다보니 카테고리 정보를 가져오는 과정에서 N+1 문제가 발생했습니다.

😰 N+1 문제?

JPA를 사용할 때 반드시 만나게 될 이슈입니다. 엔티티를 조회할 때 조회 된 데이터 갯수만큼 연관관계에 대한 조회 쿼리가 추가로 발생하는 상황을 의미합니다.

게시글과 카테고리 간에 연관관계를 조회하는 과정에서 N+1 이 발생했습니다. 페이징이 끝난 후, 해당 게시글과 관련이 있는 모든 정보를 JOIN해야 했고, LazyFetch 를 통해 카테고리의 대분류/중분류/소분류 데이터를 만드는 과정에서 쿼리가 더 전달 된 것입니다.

N+1 문제를 해결하는 방안은 여러가지가 있습니다. 현재 상황에서는 @OneToMany 로 조회되는 데이터에 대해 개선이 필요하겠죠. 보통 FetchJoin을 많이 사용합니다. 해당 정보에 대해 함께 JOIN해서 가져오도록 명세하는 방법이죠. @OneToMany 관계라면 @EntityGraph 기능을 주로 사용합니다.

해당 방법을 고민하지 않은 것은 아니나, 당시에 시간이 부족했기 때문에 쿼리를 나누는 형태로 해결했습니다.

😁 문제 해결 과정

먼저 모든 카테고리의 경우를 조회하는 쿼리를 전달했습니다. 쿼리의 결과는 아래와 같이 나오게 됩니다.

대분류 카테고리대분류 코드중분류 카테고리중분류 코드소분류 카테고리소분류 코드
게시판CATEGORY_01자유게시판CATEGORY_L_01Q&ACATEGORY_M_01

이렇게 해결했던 이유는 카테고리 데이터가 그렇게 많지 않았고 (모든 카테고리의 경우의 수가 50개가 채 되지 않았습니다. 그정도는 트레이드 오프 해도 괜찮다고 판단했죠.), 조회 쿼리가 상당히 복잡했습니다. 게시글 테이블을 검색하는 데 JOIN 해야 하는 테이블이 CategoryInfo, Category, CategoryClosure 총 세개였고, 검색 조건으로는 카테고리에 대한 필터링도 요구사항에 포함되었습니다.

실제 검색 결과는 게시글과 카테고리 간에 1대 N 관계 때문에 중복을 제거해야했습니다. 이런 상황에서 @EntityGraph 를 도입한다고 해도 결국 조회쿼리가 나가는것은 마찬가지라 생각했었습니다.

시도 해 보고 후기 남기겠습니다. 사실 시간이 부족했고 @EntityGraph 까지 고민하자니 정신도 없었고 제 JPA 경험이 부족했었네요. 일단 1차 배포하고 시도해볼까 생각중입니다.

중복을 제거하는 기준을 Group By SEQ 가 아니라 소분류 카테고리로 잡았습니다. 앞서 전달했던 모든 카테고리에 대한 조회쿼리에서 소분류 카테고리를 키값으로 둔다면, HashMap을 통해 빠르게 게시글의 카테고리 정보를 알 수 있을 것이라 생각했기 때문입니다. 게시글의 소분류 카테고리 정보는 JOIN하는 CategoryInfo 테이블을 통해 가져왔습니다. 일대다로 매핑 된 카테고리의 ID는 CategoryInfo를 통해 가져올 수 있지만, 이름을 가져오기 위해 Category 테이블을 조인해야 했죠.

😀 성과 및 리뷰

먼저 페이징이 진행되고 나서 Frontend로 전달 할 DTO (실제 화면에 필요한 데이터로 변환해야했습니다.)로 변환하는 과정에서 카테고리 정보들을 조회하는 쿼리를 통해 생성 된 HashMap을 생성했고, 나머지 대분류/중분류/소분류 정보를 가져오는 방식을 통해 최종 Response VO를 생성하여 전달했고, 결과는 최대 3배 이상의 성능 개선이 있었습니다. 1초에서 1.7초까지도 늘어지던 API 처리 속도가 500ms 대로 줄었습니다. 실제로 서비스를 제공하는 데 큰 지장이 없을 만큼 큰 개선이 있었습니다.

하지만, 앞서 말씀드렸듯 JPA에서 제공되는 모든 기능을 사용 한 것은 아니었습니다. QueryDSL을 사용하는 상황에서 쿼리를 작성하는 데 집중하다보니 @EntityGraph 라는 또 다른 방법에 대해 자세히 알아 볼 생각을 못하고 빠르게 당장 문제를 해결 할 수 있는 방법을 선택했습니다.

물론 정답은 없습니다. JPA는 완벽하지 않고 기껏해야 50개 정도 나오는 카테고리 정보들을 조회하는 쿼리의 비용은 비싸지 않습니다. 또한 페이지 당 데이터 만큼 Java Stream API 를 타 봐야 큰 성능이슈는 없을 것이라 판단했습니다.

조금 더 코드를 간결하게 개선하고자 한다면 @EntityGraph를 사용 해 보는 것을 고려해볼 수 있으나, 사실 해당 방식은 아직 시도 해 본적이 없기 때문에 또 다른 해결책이라고 보장할 수는 없는 상황이었습니다.

제가 JPA에 대한 경험이 부족하기 때문에 적극적으로 해당 방법을 시도하지 않은 것 문제를 해결하는 데 시간을 소요 한 원인이었고, 이번 기회에 JPA에 대한 책을 더 읽어보게 된 계기가 된 것 같습니다.

🚀 Outro

이번 트러블 슈팅 건을 통해 조금 아쉽지만 어쨌든 이슈를 해결하였고, JPA에 대해 깊게 공부를 해봐야겠다는 생각이 들었습니다. 사실 흔히 많이들 보시는 JPA 교재로 공부를 시작했던 것은 아니고, 당연히 백엔드의 시작을 Spring으로 하다보니 경험으로 JPA를 알게 된 것이었습니다. 당연히 깊은 지식이 없어서 아쉬운 상황을 마주하게 될 것이고 이번 경험이 큰 자극제가 되었습니다.

마침 곧 월급도 들어오는데 남은 2023년 동안 토비의 스프링과 JPA 책 한권 정도 사서 완독하는 목표를 세우게 되는 경험이었습니다. 이번 이슈 외에도 리펙토링과 관련 되어서도 깊은 고민을 하게 되었는데, Github에 배포 된 다양한 백엔드 코드들을 살펴보다보면 아직까지도 제 백엔드 코드는 '객체간에 책임의 분배에 자유로운지 잘 모르겠다.' 라는 고민이 들었습니다. 그게 꼭 Spring이 아니어도 말이죠.

2년 조금 안되는 시간동안 백엔드, 프론트엔드, 데브옵스 등 경험을 쌓고 있었지만, 막상 내가 본질을 제대로 보고 있는지에 대한 의문에 직격타를 때려주는 고마운 경험이었습니다. 블로그에 기록하는 와중에도 고민들이 정리가 되네요.